--[[## ft=lua ##################################################################
  This file is licensed under [WTFPL v2](https://www.wtfpl.net/).  You may read
  the full license in the file LICENSE or by following the web link.

  MUBAN - a templating system written in Lua focused on code generation.

    Future Growth:

    Currently this library is useful and powerful.  What is needed in the future
    however needs some annotation.

    - An optional mechanism for escaping the generated text for specific targets
      might be nice.  For example if generating HTML it might be nice to have:

            & -> &amp;
            < -> &lt;
            > -> &gt;
            " -> &quot;
            ' -> &#39;'

      Such a mechanism would supply defaults for common targets (Lua, HTML, C,
      etc.) as well as a way to register their own escaping function.

    - More ambitiously the ability for users to form custom blocks would be
      interesting.  Currently there are four block distinguishing characters
      giving us the {{=, {{!, {{- and {{# blocks.  There are plenty of other
      symbol characters we can use.  Perhaps the ability to register such user
      customized blocks would be useful.
--############################################################################]]

local table = require 'tablex'
local M = {}

-- build throwable errors with extra information available
local function build_exception(...)
    local t = { ... }
    return setmetatable(t, {
        __tostring = function(self)
            if self.trace then
                return string.format('%s\nInclude trace:\n  %s',
                    string.format(table.unpack(self)),
                    table.concat(self.trace, ' -> '))
            end
            return string.format(table.unpack(self))
        end
    })
end

-- run a compiled template to generate the resulting text
local function run(chunks, env)

    -- build a sandbox for legal operations
    -- this will be critical for future versions which will permit user
    -- customization
    local sandbox = {
        tostring = tostring,
        table = table,
        pairs = pairs,
        ipairs = ipairs,
        _source = {}  -- Output buffer
    }

    -- import the user-supplied variables for use in expansions
    if env then
        for k, v in pairs(env) do
            sandbox[k] = v
        end
    end

    -- build up the source code that will generate our final text
    -- each chunk type/format decrees how the source code is provided
    local body = {'local _source = {}'}
    for _, chunk in ipairs(chunks) do
        if chunk.type == 'text' then
            table.insert(body, string.format('_source[#_source + 1] = %q', chunk.value))
        elseif chunk.type == 'code' then
            if chunk.format == '=' then
                if sandbox[chunk.value] ~= nil then
                    table.insert(body, string.format('_source[#_source + 1] = tostring(%s)', chunk.value))
                else
                    error(build_exception('runtime error: variable "%s" missing', chunk.value))
                end
            else -- '!'
                table.insert(body, chunk.value)  -- Raw Lua code
            end
        end
    end
    table.insert(body, 'return table.concat(_source)')
    local full_code = table.concat(body, '\n')

    -- source now completed by assembled chunks, compile the ensuing code in Lua
    local fn, err = load(full_code, '=(template)', 't', sandbox)
    if not fn then
        error(build_exception('template compile error: %s', err))
    end

    -- and run the prepared chunk
    local success, result = pcall(fn)
    if not success then
        error(build_exception('template runtime error: %s', result))
    end

    return result
end

-- break down a template into a collection of differently-typed 'chunks'
-- each 'chunk' will be dealt with differently in the runner
-- the first argument is the module table and should be ignored
-- the second argument is the text of the template
-- the third argument is an optional compilation context used to help with meaningul error messages
-- the fourth argument is a stealth argument used in {{#...}} for inclusion
local function compile(_, template, compile_ctx, chunks)
    chunks = chunks or {}
    compile_ctx = compile_ctx or {}
    compile_ctx.filename = compile_ctx.filename or '(string)'
    compile_ctx.line = compile_ctx.line or 1
    compile_ctx.include_stack = compile_ctx.include_stack or {}

    local pos = 1
    while pos <= #template do
        -- Find next template tag
        local begin = template:find('{{', pos)
        if not begin then
            -- Add remaining text as literal chunk
            if pos <= #template then
                table.insert(chunks, { type = 'text', value = template:sub(pos) })
            end
            break
        end

        -- Track line numbers
        local newline_count = select(2, template:sub(pos, begin - 1):gsub('\n', '\n'))
        compile_ctx.line = compile_ctx.line + newline_count

        -- Check for escaped tag
        local is_escaped = begin > 1 and template:sub(begin - 1, begin - 1) == '\\'
        local has_double_backslash = begin > 2 and template:sub(begin - 2, begin - 1) == '\\\\'

        -- Add text before the block (handling backslashes)
        if begin > pos then
            local text_before = template:sub(pos, begin - (is_escaped and 2 or 1))
            if has_double_backslash then
                -- Replace '\\' with '\' before the block
                text_before = text_before:sub(1, -2) .. '\\'
            end
            table.insert(chunks, { type = 'text', value = text_before })
        end

        if is_escaped and not has_double_backslash then
            -- Literal '{{'
            table.insert(chunks, { type = 'text', value = '{{' })
            pos = begin + 2
        else
            -- Process template tag
            local block_end = template:find('}}', begin)
            if not block_end then
                error(build_exception(
                    'in %s:%d: unclosed template tag',
                    compile_ctx.filename,
                    compile_ctx.line
                ))
            end

            local block_content = template:sub(begin + 2, block_end - 1)
            local tag = block_content:sub(1, 1)
            local content = block_content:sub(2)

            if tag == '=' or tag == '!' then
                table.insert(chunks, {
                    type = 'code',
                    value = content,
                    format = tag
                })
            elseif tag == '#' then
                content = string.match(content, '^[%s]*(.-)[%s]*$')
                for _, included in ipairs(compile_ctx.include_stack) do
                    if included.filename == content then
                        local err = build_exception(
                            'in %s:%d: circular include "%s" (first seen at %s:%d)',
                            compile_ctx.filename,
                            compile_ctx.line,
                            content,
                            included.filename,
                            included.line
                        )
                        error(err)
                    end
                end

                local file, file_err = io.open(content, 'r')
                if not file then
                    error(build_exception(
                        'in %s:%d: failed to open "%s": %s',
                        compile_ctx.filename,
                        compile_ctx.line,
                        content,
                        file_err
                    ))
                end

                local new_include_stack = {
                    {
                        filename = compile_ctx.filename,
                        line = compile_ctx.line
                    }
                }
                for _, entry in ipairs(compile_ctx.include_stack) do
                    table.insert(new_include_stack, entry)
                end

                compile(_, file:read('*a'), {
                    filename = content,
                    line = 1,
                    include_stack = new_include_stack
                }, chunks)
                file:close()
            elseif tag == '-' then
                -- Comment (ignore)
            else
                error(build_exception(
                    'in %s:%d: unknown tag "{{%s"',
                    compile_ctx.filename,
                    compile_ctx.line,
                    tag
                ))
            end
            pos = block_end + 2
        end
    end

    return setmetatable(chunks, { __call = run })
end

return setmetatable({}, { __call = compile })
